5.19. Типы данных
Типы данных
Типы данных в Elixir делятся на две большие категории: базовые (или примитивные) и составные. Базовые типы представляют собой простые значения, которые не содержат внутри себя других структур. Составные типы строятся из базовых или других составных элементов и позволяют моделировать сложные формы информации. Все типы в Elixir реализованы как значения, передаваемые по значению, а не по ссылке, что упрощает рассуждения о поведении программы и делает её более предсказуемой.
Целые числа
Целые числа в Elixir — это значения без дробной части, которые могут быть как положительными, так и отрицательными. Язык поддерживает произвольную точность целых чисел, что означает отсутствие ограничений на размер числа, кроме доступной памяти. Разработчик может свободно работать с числами, содержащими тысячи или даже миллионы цифр, без необходимости использовать специальные библиотеки. Такая гибкость особенно полезна в криптографии, математических вычислениях и других областях, где требуется высокая точность.
Целые числа можно записывать в десятичной, шестнадцатеричной, восьмеричной и двоичной системах счисления. Для этого используются префиксы: 0x для шестнадцатеричных, 0o для восьмеричных и 0b для двоичных литералов. Например, число 255 может быть записано как 0xFF, 0o377 или 0b11111111. Все эти формы эквивалентны и интерпретируются одинаково во время выполнения программы.
Числа с плавающей точкой
Числа с плавающей точкой в Elixir соответствуют стандарту IEEE 754 двойной точности (64-битные). Они используются для представления вещественных чисел, то есть значений, которые могут содержать дробную часть. Запись таких чисел осуществляется в десятичной форме с обязательным наличием точки, даже если дробная часть отсутствует. Например, 3.0 — корректное число с плавающей точкой, тогда как 3 будет воспринято как целое число.
Арифметические операции с числами с плавающей точкой выполняются с использованием аппаратной поддержки процессора, если она доступна. Однако, как и в большинстве языков программирования, такие операции могут быть подвержены ошибкам округления из-за особенностей двоичного представления десятичных дробей. Это требует внимания при сравнении значений или выполнении финансовых расчётов, где важна абсолютная точность.
Атомы
Атомы — одна из самых характерных черт Elixir и всей экосистемы Erlang. Атом представляет собой константу, имя которой одновременно является её значением. Атомы начинаются с двоеточия, например: :ok, :error, :user_id. Они часто используются для обозначения состояний, меток, ключей в структурах данных или возвращаемых значений функций. Поскольку атомы неизменяемы и глобально уникальны, их сравнение происходит за константное время, что делает их чрезвычайно эффективными для использования в условиях высокой нагрузки.
Важно понимать, что атомы хранятся в специальной таблице атомов, которая имеет ограниченный размер. Создание слишком большого количества атомов во время выполнения программы может привести к исчерпанию этой таблицы и аварийному завершению системы. По этой причине атомы не должны создаваться динамически из ненадёжных источников, таких как пользовательский ввод. Вместо этого рекомендуется использовать строки или другие типы данных, когда требуется гибкость в формировании имён.
Строки
Строки в Elixir — это последовательности байтов, закодированные в формате UTF-8. Это означает, что каждая строка является бинарным объектом, который может содержать любой текст, включая символы всех известных языков мира, эмодзи и специальные символы. Строки записываются в двойных кавыках: "Привет, мир!".
Поскольку строки в Elixir реализованы как бинарники, они поддерживают все операции, доступные для бинарных данных. При этом язык предоставляет богатый набор функций для работы с текстом: поиск подстрок, замена, разбиение, преобразование регистра и многое другое. Эти функции находятся в модуле String, который является частью стандартной библиотеки.
Особенностью строк в Elixir является их неизменяемость. Любая операция, изменяющая строку, создаёт новый бинарный объект. Это может показаться неэффективным на первый взгляд, но благодаря механизму копирования при записи (copy-on-write) и оптимизациям на уровне BEAM, такие операции выполняются быстро и с минимальным расходом памяти.
Символы и кодовые точки
Хотя Elixir не имеет отдельного типа «символ» в том виде, в каком он существует в некоторых других языках, он предоставляет работу с кодовыми точками Unicode. Каждый символ в строке представлен своей кодовой точкой — целым числом, соответствующим стандарту Unicode. Функции из модуля String позволяют перебирать строку по кодовым точкам, получать их числовые значения и выполнять различные преобразования.
Для удобства записи отдельных символов используется синтаксис ?a, который возвращает кодовую точку символа a (в данном случае — 97). Этот подход позволяет легко работать с отдельными символами без необходимости извлекать их из строк.
Кортежи (Tuples)
Кортеж — это упорядоченная коллекция элементов фиксированного размера. Кортежи в Elixir записываются в фигурных скобках, например: {:ok, "файл загружен"} или {1, 2, 3}. Каждый элемент может быть любого типа: число, строка, атом, другой кортеж — ограничений нет. Размер кортежа определяется во время его создания и не может быть изменён позже.
Кортежи часто используются для возврата нескольких значений из функции, особенно для передачи результата операции вместе со статусом. Стандартная практика в экосистеме Elixir — возвращать пару вида {:ok, значение} при успехе и {:error, причина} при ошибке. Такой подход делает обработку исключений явной и предсказуемой.
Пример:
File.read("example.txt")
# Может вернуть:
# {:ok, "Содержимое файла"}
# или
# {:error, :enoent}
Доступ к элементам кортежа осуществляется по индексу с помощью функции elem/2:
data = {:user, "Алексей", 34}
name = elem(data, 1) # "Алексей"
age = elem(data, 2) # 34
Изменение элемента кортежа невозможно напрямую, поскольку все данные в Elixir неизменяемы. Однако можно создать новый кортеж на основе старого с помощью синтаксиса обновления:
person = {:user, "Мария", 28}
updated_person = put_elem(person, 2, 29) # {:user, "Мария", 29}
Функция put_elem/3 принимает исходный кортеж, индекс и новое значение, возвращая новый кортеж. Это соответствует общей философии языка: вместо модификации — создание нового значения.
Списки (Lists)
Список в Elixir — это односвязный список, реализованный как цепочка пар «голова–хвост». Голова содержит первый элемент, хвост — оставшуюся часть списка (или пустой список, если элемент последний). Списки записываются в квадратных скобках: [1, 2, 3], ["привет", "мир"].
Особенность списков — эффективное добавление элементов в начало. Операция [элемент | список] выполняется за константное время. Добавление в конец или произвольный доступ по индексу требует прохода по всему списку и имеет линейную сложность.
Примеры:
list = [1, 2, 3]
new_list = [0 | list] # [0, 1, 2, 3]
# Разбор списка через сопоставление с образцом
[head | tail] = new_list
# head == 0
# tail == [1, 2, 3]
Списки могут содержать элементы разных типов:
mixed = [:status, "готово", 200]
Однако в реальных программах рекомендуется использовать однородные списки для повышения читаемости и поддержки типовой дисциплины.
Проверка принадлежности элемента списку:
1 in [1, 2, 3] # true
5 in [1, 2, 3] # false
Длина списка определяется функцией length/1:
length([:a, :b, :c]) # 3
Важно не путать списки с кортежами: списки предназначены для хранения последовательностей переменной длины, кортежи — для фиксированных наборов связанных значений.
Бинарники (Binaries)
Бинарник — это последовательность байтов. Он представляет собой непрерывный блок памяти, используемый для хранения сырых данных: изображений, сетевых пакетов, сериализованных структур и, что особенно важно, строк. В Elixir строки являются UTF-8-бинарниками.
Создание бинарника:
<<1, 2, 3>> # бинарник из трёх байтов
<<65, 66, 67>> # соответствует "ABC" в ASCII
"Привет" # это тоже бинарник: <<208, 159, 209, 128, ...>>
Бинарники поддерживают мощный синтаксис сопоставления с образцом, позволяющий разбирать структурированные данные на лету:
data = <<10, 20, 30>>
<<a, b, c>> = data
# a == 10, b == 20, c == 30
Можно указывать размер и тип каждого сегмента:
<<flags::8, length::16, payload::binary>> = <<1, 0, 5, "hello">>
# flags == 1
# length == 5
# payload == "hello"
Эта возможность делает Elixir особенно удобным для работы с сетевыми протоколами, файловыми форматами и другими бинарными структурами.
Конкатенация бинарников:
greeting = "Привет"
full = greeting <> ", мир!" # "Привет, мир!"
Оператор <> работает только с бинарниками и строками (которые являются их частным случаем).
Битовые строки (Bitstrings)
Битовая строка — обобщение бинарника, в котором количество битов не обязано быть кратно восьми. Бинарник — это частный случай битовой строки, где длина кратна 8.
Пример битовой строки:
<<1::3, 0::2, 1::3>> # 8 бит → эквивалентно <<0b10001001>>
<<1::1>> # 1 бит — это уже не бинарник, а битовая строка
Проверка:
is_binary(<<1::8>>) # true
is_binary(<<1::1>>) # false
is_bitstring(<<1::1>>) # true
Битовые строки редко используются в повседневном коде, но незаменимы при работе с аппаратными интерфейсами, шифрованием или компактными представлениями данных.
Диапазоны (Ranges)
Диапазон — это структура, представляющая последовательность целых чисел от начала до конца включительно. Записывается через ..:
1..5 # диапазон от 1 до 5
-3..0 # от -3 до 0
Диапазоны не генерируют все числа сразу — они хранят только границы. Это делает их экономичными по памяти.
Проверка вхождения:
3 in 1..5 # true
6 in 1..5 # false
Диапазоны часто используются в циклах и генераторах:
for n <- 1..3, do: n * n # [1, 4, 9]
Отображения (Maps)
Отображение — основная структура данных для хранения пар «ключ–значение». Ключами могут быть любые типы: атомы, строки, числа, даже другие структуры. Значения также не ограничены.
Создание:
person = %{"имя" => "Иван", "возраст" => 40}
config = %{host: "localhost", port: 8080}
Обратите внимание: при использовании атомов в качестве ключей допускается сокращённая запись ключ: значение.
Доступ к значению:
person["имя"] # "Иван"
config[:host] # "localhost"
Если ключ — атом, можно использовать точечную нотацию:
config.host # "localhost"
Обновление значения:
updated = %{config | port: 9000} # %{host: "localhost", port: 9000}
Этот синтаксис требует, чтобы ключ уже существовал. Для добавления нового ключа используется полная форма:
%{config | new_key: "значение"} # ошибка, если new_key не существует
%{config | "новый_ключ" => "значение"} # тоже ошибка
# Правильно — создать новое отображение:
Map.put(config, :timeout, 5000)
# или
%{config | timeout: 5000} # только если timeout уже есть
Лучше всего использовать Map.put/3 для универсального обновления:
Map.put(%{}, :key, "value") # %{key: "value"}
Отображения не гарантируют порядок ключей, хотя в современных версиях Elixir порядок вставки сохраняется как деталь реализации.
Переменные
В Elixir переменные — это именованные ссылки на значения. Объявление переменной происходит в момент присваивания. Синтаксис прост: имя переменной, за которым следует оператор =, и значение справа:
x = 42
name = "Елена"
status = :active
Имена переменных должны начинаться со строчной буквы или символа подчёркивания и могут содержать буквы, цифры, знаки подчёркивания и символы @ (в особых случаях). Примеры допустимых имён: counter, user_id, _temp, max_value.
Особенность переменных в Elixir — их связь с механизмом сопоставления с образцом (pattern matching). Оператор = не является классическим присваиванием, как в императивных языках. Он выражает утверждение: «левая часть должна соответствовать правой». Если соответствие возможно, переменные в левой части связываются со значениями из правой части. Если нет — возникает ошибка времени выполнения.
Пример:
{a, b} = {10, 20}
# a == 10, b == 20
Здесь переменные a и b связываются с элементами кортежа. Это не «извлечение», а декларативное утверждение о структуре данных.
Переменные в Elixir могут быть связаны только один раз в рамках одного контекста. Повторное использование имени переменной приводит не к изменению значения, а к пересвязыванию (rebinding). Это означает, что старая переменная остаётся неизменной, а новое имя указывает на новое значение. Такое поведение сохраняет неизменяемость данных, но даёт удобство в написании кода.
Пример:
x = 5
x = x + 1
# x теперь связано со значением 6
# исходное значение 5 не изменилось — просто создано новое связывание
Пересвязывание работает только с неаннотированными переменными. Если переменная используется в левой части сопоставления внутри функции или блока, где она уже была связана, интерпретатор требует точного совпадения, если не указано иное.
Чтобы запретить пересвязывание и заставить переменную вести себя как константа, можно использовать модульный атрибут или явно передать значение в замыкание без повторного связывания. Однако в большинстве случаев пересвязывание считается нормальной практикой и не нарушает функциональной чистоты.
Имена, начинающиеся с заглавной буквы, зарезервированы для модулей и не могут использоваться как переменные:
User = "admin" # ошибка: User — это имя модуля
Переменные, начинающиеся с подчёркивания, сигнализируют о том, что значение не будет использоваться. Это соглашение помогает избежать предупреждений компилятора о неиспользуемых переменных:
{_status, data} = File.read("config.txt")
# _status игнорируется, data используется далее
Структуры (Structs)
Структура — это расширение отображения с фиксированным набором ключей, определённых во время компиляции. Структуры обеспечивают типобезопасность, читаемость и производительность при работе с именованными данными. Каждая структура привязана к модулю, и её имя совпадает с именем модуля.
Объявление структуры:
defmodule User do
defstruct name: "", age: 0, active: true
end
Создание экземпляра:
user = %User{name: "Дмитрий", age: 31}
# %User{name: "Дмитрий", age: 31, active: true}
Поля, не указанные при создании, получают значения по умолчанию, заданные в defstruct.
Доступ к полям:
user.name # "Дмитрий"
user.age # 31
Обновление:
updated = %{user | age: 32}
Попытка добавить поле, не объявленное в структуре, вызовет ошибку:
%{user | role: "admin"} # ** (KeyError) key :role not found
Это отличает структуры от обычных отображений и делает их подходящими для моделирования доменных сущностей.
Структуры наследуют все свойства отображений, но имеют собственный тип, что позволяет функциям проверять принадлежность к конкретной структуре:
is_struct(user) # true
is_map(user) # true (структура — подтип отображения)
Можно определять методы внутри модуля структуры, создавая поведение, связанное с данными:
defmodule User do
defstruct name: "", age: 0
def greet(%User{name: name}) do
"Привет, #{name}!"
end
end
user = %User{name: "Анна"}
User.greet(user) # "Привет, Анна!"
Функции как тип данных
В Elixir функции являются полноценными значениями первого класса. Их можно присваивать переменным, передавать как аргументы, возвращать из других функций и хранить в структурах данных.
Функции создаются с помощью ключевого слова fn:
adder = fn a, b -> a + b end
result = adder.(3, 4) # 7
Обратите внимание на синтаксис вызова: после имени переменной ставится точка и скобки. Это отличает вызов анонимной функции от вызова именованной функции модуля.
Функции могут захватывать окружение, в котором были определены (замыкания):
multiplier = 10
scale = fn x -> x * multiplier end
scale.(5) # 50
Здесь multiplier — свободная переменная, захваченная из внешнего контекста.
Именованные функции модуля также могут быть представлены как значения с помощью оператора &:
double = &(&1 * 2)
# эквивалентно fn x -> x * 2 end
Enum.map([1, 2, 3], double) # [2, 4, 6]
# Или ссылка на функцию модуля:
formatter = &String.upcase/1
formatter.("hello") # "HELLO"
Функции в Elixir неизменяемы и не имеют состояния. Они всегда возвращают одно и то же значение при одинаковых входных данных, что делает их чистыми (pure) по умолчанию, если не используют побочные эффекты.
Процессы и PID
Elixir построен на модели акторов: каждая единица выполнения — это изолированный процесс, который взаимодействует с другими процессами исключительно через обмен сообщениями. Эти процессы управляются виртуальной машиной BEAM и не имеют отношения к операционным потокам или процессам ОС. Они легковесны, потребляют мало памяти (около 2–3 КБ на процесс) и могут запускаться в количестве миллионов на одной машине.
Каждый процесс имеет уникальный идентификатор — PID (Process Identifier). PID — это специальный тип данных, который можно получить при создании процесса:
pid = spawn(fn ->
receive do
{:hello, sender} -> send(sender, {:world})
end
end)
PID выглядит как #PID<0.123.0> и может использоваться для отправки сообщений:
send(pid, {:hello, self()})
Функция self() возвращает PID текущего процесса.
Процессы не разделяют память. Любые данные, передаваемые между ними, копируются. Это исключает гонки данных и делает программу устойчивой к сбоям: падение одного процесса не влияет на другие.
Проверка типа:
is_pid(pid) # true
PID остаётся действительным даже после завершения процесса, но попытка отправить сообщение мёртвому процессу приведёт к его тихому игнорированию.
Процессы — основа конкурентности в Elixir. Они позволяют строить отказоустойчивые, масштабируемые и отзывчивые системы без использования блокировок, мьютексов или других примитивов синхронизации.